package com.googlecode.java_cl_parser;

import org.apache.commons.cli.*;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.velocity.VelocityContext;
import org.apache.velocity.app.VelocityEngine;

import java.io.IOException;
import java.io.StringWriter;
import java.lang.annotation.Annotation;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.sql.Timestamp;
import java.util.*;
import java.util.regex.Pattern;

public class CommonsCLILauncher
{
	public static final String HELP_INVALID_USAGE_MESSAGE = "Help is not valid in combination with any other option";
	public static final String HELP_USAGE_MESSAGE = "Help requested";

	enum data_type
	{
		bool,
		Bool,
		long_integer,
		Long,
		integer,
		Integer,
		string,
		String,
		Object
	}

	enum cardinal_type
	{
		instance,
		array,
		flag
	}

	static final class Data
	{
		data_type type = null;
		cardinal_type cardinality = null;
		Method setter;
		CLIOption clioption;
	}

	private static final Map<String, String> reservedOptionNames = new HashMap<String, String>();

	static
	{
		reservedOptionNames.put("?", "?");
		reservedOptionNames.put("help", "help");
		reservedOptionNames.put("verbose", "verbose");
		reservedOptionNames.put("v", "v");
	}

	private static final CLIOption help = new CLIOption(){
		public String name()
		{
			return "?";
		}

		public String longName()
		{
			return "help";
		}

		public String description()
		{
			return "Prints this usage description.";
		}

		public boolean required()
		{
			return false;
		}

		public value_type valueType()
		{
			return value_type.not_allowed;
		}

		public int valueCardinality()
		{
			return 0;
		}

		public String valueSeparator()
		{
			return "\0";
		}

		public String defaultValue()
		{
			return "\0";
		}

		public String[] enumeratedValues()
		{
			return new String[0];
		}

		public Class<? extends Annotation> annotationType()
		{
			return CLIEntry.class;
		}
	};

	private static final CLIOption verbose = new CLIOption(){
		public String name()
		{
			return "v";
		}

		public String longName()
		{
			return "verbose";
		}

		public String description()
		{
			return "Displays the command line options.";
		}

		public boolean required()
		{
			return false;
		}

		public value_type valueType()
		{
			return value_type.not_allowed;
		}

		public int valueCardinality()
		{
			return 0;
		}

		public String valueSeparator()
		{
			return "\0";
		}

		public String defaultValue()
		{
			return "\0";
		}

		public String[] enumeratedValues()
		{
			return new String[0];
		}

		public Class<? extends Annotation> annotationType()
		{
			return CLIEntry.class;
		}
	};

	private static final CLIOption [] impliedOptions = new CLIOption[] {
			help,
			verbose
	};

	public static Object mainClean(String[] argv)
	{
		try
		{
			return main(argv);
		}
		catch (UsageException e)
		{
			if (e.getMessage() != HELP_USAGE_MESSAGE)
			{
				System.out.println(e.getMessage());
			}

			System.out.println(e.getFormattedUsageStatement());
		}
		catch (InvalidCLIEntryException e)
		{
			e.printStackTrace(System.err);
		}
		catch (MissingCLIEntryException e)
		{
			e.printStackTrace(System.err);
		}

		return null;
	}

	public static Object main(String[] argv) throws InvalidCLIEntryException, UsageException, MissingCLIEntryException
	{
		// the protocol is system property, then call stack
		String val = System.getProperty("com.googlecode.java_cl_parser.CLIMain");

		if (val != null)
		{
			Object launchable = resolveLaunchable(val);
			return mainWithInstance(launchable, argv);
		}

		// determine the appropriate caller
		StackTraceElement[] st = Thread.currentThread().getStackTrace();

		// ignoring the last element, which will be the current class, move up the chain
		// and grab the first CLIEntry you find
		for (int i = 1; i < st.length; i++)
		{
			String name = st[i].getClassName();

			try
			{
				Object launchable = resolveLaunchable(name);
				return mainWithInstance(launchable, argv);
			}
			catch(MissingCLIEntryException exc)
			{
				// go on to try the next one
			}
		}

		// fail loudly
		throw new MissingCLIEntryException("No acceptable CLIEntry found on the stack.");
	}

	private static Object resolveLaunchable(String name)
			throws InvalidCLIEntryException, MissingCLIEntryException, UsageException
	{
		try
		{
			Class<?> cl = Class.forName(name);

			// look for the marker annotation
			CLIEntry annot = cl.getAnnotation(CLIEntry.class);

			if (annot != null)
			{
				// try this one
				// next requirement is exactly one CLIMain annotation
				int mains = 0;
				for (Method method : cl.getMethods())
				{
					CLIMain climain = method.getAnnotation(CLIMain.class);

					if (climain != null)
					{
						mains++;
					}
				}

				if (mains == 0)
				{
					throw new InvalidCLIEntryException("Class [" + cl + "] has a CLIEntry annotation, but no methods annotated with CLIMain", InvalidCLIEntryException.invalid_cause.missing_cli_main);
				}
				else if (mains > 1)
				{
					throw new InvalidCLIEntryException("Class [" + cl + "] has a CLIEntry annotation, but more than one method annotated with CLIMain", InvalidCLIEntryException.invalid_cause.redundant_cli_main);
				}

				// bail - our work is done
				return cl.newInstance();
			}

			throw new MissingCLIEntryException();
		}
		catch (ClassNotFoundException e)
		{
			// this class is not available to us - flag it as missing
			throw new MissingCLIEntryException(e);
		}
		catch (InstantiationException e)
		{
			throw new InvalidCLIEntryException(e, InvalidCLIEntryException.invalid_cause.class_access_error);
		}
		catch (IllegalAccessException e)
		{
			throw new InvalidCLIEntryException(e, InvalidCLIEntryException.invalid_cause.class_access_error);
		}
	}

	public static Object mainWithInstanceClean(Object obj, String[] argv)
	{
		try
		{
			return mainWithInstance(obj, argv);
		}
		catch (UsageException e)
		{
			if (e.getMessage() != HELP_USAGE_MESSAGE)
			{
				System.out.println(e.getMessage());
			}

			System.out.println(e.getFormattedUsageStatement());
			return null;
		}
		catch (InvalidCLIEntryException e)
		{
			e.printStackTrace(System.out);
			return null;
		}
	}

	public static Object mainWithInstance(Object obj, String[] argv)
			throws UsageException, InvalidCLIEntryException
	{
		Options options = new Options();
		Map<String, String> instanceMap = new HashMap<String, String>();
		Map<String, Object> valueMap = new HashMap<String, Object>();
		List<Data> typeList = new ArrayList<Data>();

		Data d = new Data();
		d.clioption = help;
		d.cardinality = cardinal_type.flag;

		typeList.add(d);

		d = new Data();
		d.clioption = verbose;
		d.cardinality = cardinal_type.flag;

		typeList.add(d);

		// look through the annotations for methods of this class, and create options for each
		Method[] methList = obj.getClass().getMethods();

		for (int i = 0; i < methList.length; i++)
		{
			CLIOption annot = methList[i].getAnnotation(CLIOption.class);

			if (annot != null)
			{
				// validate that this parameter is not one of the reserved options
				if (reservedOptionNames.containsKey(annot.name()) || reservedOptionNames.containsKey(annot.longName()))
				{
					throw new InvalidCLIEntryException("Option name [" + annot.name() + "] or long name [" + annot.longName() + "] is reserved", InvalidCLIEntryException.invalid_cause.bad_options);
				}

				// validate some basic bad configurations
				assertValid(annot);
				// validate that this is a method which accepts a single parameter
				Class<?>[] parameterTypes = methList[i].getParameterTypes();

				Data data = new Data();
				data.setter = methList[i];
				data.clioption = annot;

				if (instanceMap.containsKey(annot.name()))
				{
					throw new InvalidCLIEntryException("Option [" + annot.name() + "] specified more than once", InvalidCLIEntryException.invalid_cause.bad_options);
				}
				else if (instanceMap.containsKey(annot.longName()))
				{
					throw new InvalidCLIEntryException("Option [" + annot.longName() + "] specified more than once", InvalidCLIEntryException.invalid_cause.bad_options);
				}

				instanceMap.put(annot.name(), "");

				if (!annot.longName().equals("\0"))
				{
					instanceMap.put(annot.longName(), "");
				}

				typeList.add(data);

				if (parameterTypes == null || parameterTypes.length != 1)
				{
					throw new InvalidCLIEntryException("Setter for option ["
						+ annot.name()
						+ "] does not accept exactly one parameter: "
						+ methList[i], InvalidCLIEntryException.invalid_cause.bad_options);
				}

				// if the cardinality is anything other than 1, the parameter must be an array type
				Class<?> parameterType = parameterTypes[0];
				Class<?> arrayComponentType = parameterType.isArray() ? parameterType.getComponentType() : parameterType;

				if (parameterType.isArray())
				{
					data.cardinality = cardinal_type.array;
				}
				else
				{
					data.cardinality = cardinal_type.instance;
				}

				// validate that the type of the parameter is within bounds
				if (arrayComponentType.equals(Object.class))
				{
					data.type = data_type.Object;
				}
				else if (arrayComponentType.equals(String.class))
				{
					data.type = data_type.String;
				}
				else if (arrayComponentType.equals(boolean.class))
				{
					data.type = data_type.bool;

					if (data.cardinality != cardinal_type.array)
					{
						data.cardinality = cardinal_type.flag;
					}
				}
				else if (arrayComponentType.equals(Boolean.class))
				{
					data.type = data_type.Bool;

					if (data.cardinality != cardinal_type.array)
					{
						data.cardinality = cardinal_type.flag;
					}
				}
				else if (arrayComponentType.equals(int.class))
				{
					data.type = data_type.integer;
				}
				else if (arrayComponentType.equals(Integer.class))
				{
					data.type = data_type.Integer;
				}
				else if (arrayComponentType.equals(long.class))
				{
					data.type = data_type.long_integer;
				}
				else if (arrayComponentType.equals(Long.class))
				{
					data.type = data_type.Long;
				}

				if (data.type == null)
				{
					throw new InvalidCLIEntryException("Setter for option ["
							+ annot.name()
							+ "] does not accept a valid parameter type: "
							+ methList[i], InvalidCLIEntryException.invalid_cause.bad_options);
				}

				if (annot.valueCardinality() == 1)
				{
					if (data.cardinality == cardinal_type.array)
					{
						throw new InvalidCLIEntryException("Setter for option ["
								+ annot.name()
								+ "] must not use an array type: "
								+ methList[i], InvalidCLIEntryException.invalid_cause.bad_options);
					}
				}
				else
				{
					if (data.cardinality != cardinal_type.array)
					{
						throw new InvalidCLIEntryException("Setter for option ["
								+ annot.name()
								+ "] requires an array type: "
								+ methList[i], InvalidCLIEntryException.invalid_cause.bad_options);
					}
				}
			}
		}

		for (Data data : typeList)
		{
				//here is an option - check it out
				Option option = new Option(
						data.clioption.name(),
						data.clioption.longName().equals("\0") ? null : data.clioption.longName(),
						data.clioption.valueType() != CLIOption.value_type.not_allowed,
						data.clioption.description()
				);

				option.setRequired(data.clioption.required());

				// in the case of arguments with values, get the value cardinalities worked out
				if (data.clioption.valueType() != CLIOption.value_type.not_allowed)
				{
					option.setArgs(1);

					if (data.clioption.valueType() == CLIOption.value_type.required)
					{
						option.setOptionalArg(false);
					}
					else if (data.clioption.valueType() == CLIOption.value_type.optional)
					{
						option.setOptionalArg(true);
					}
				}
				else
				{
					option.setArgs(0);
					option.setOptionalArg(false);
				}

				//option.setType();
				options.addOption(option);
		}

		// initially, check for the help option.  This must be done before apache gets to it.
		for (String option : argv)
		{
			// this is the signal to throw a usage error
			if (option.equals("-?") || option.equals("--help"))
			{
				if (argv.length != 1)
				{
					throw new UsageException(obj, typeList, HELP_INVALID_USAGE_MESSAGE);
				}

				throw new UsageException(obj, typeList, HELP_USAGE_MESSAGE);
			}
		}

		//options are created - pass the result to the command line
		CommandLineParser clp = null;

		switch (getParserType(obj))
		{
			case gnu:
				clp = new GnuParser();
				break;
			case posix:
				clp = new PosixParser();
				break;
			case basic:
				clp = new BasicParser();
				break;
		}

		try
		{
			CommandLine cl = clp.parse(options, argv);

			boolean vbose = false;

			if (cl.hasOption(help.name()) || cl.hasOption(help.longName()))
			{
				throw new UsageException(obj, typeList, HELP_INVALID_USAGE_MESSAGE);
			}

			if (cl.hasOption(verbose.name()) || cl.hasOption(verbose.longName()))
			{
				vbose = true;
			}

			// run through all options and pass the values on with injection
			for (Data data : typeList)
			{
				boolean opted = cl.hasOption(data.clioption.name());

				// skip when there is no setter
				if (data.setter == null)
				{
					continue;
				}

				Object value;

				if (!opted)
				{
					if (data.clioption.defaultValue().equals("") && data.clioption.required())
					{
						throw new UsageException(obj, typeList, "Value not specified ["
								+ data.clioption.name()
								+ "] and no suitable default exists");
					}
				}

				try
				{
					value =
						resolveOptionValue(data,
							cl,
							data.clioption.defaultValue().equals("\0") ? null : data.clioption.defaultValue());
					valueMap.put(StringUtils.rightPad(StringUtils.abbreviate(data.clioption.longName(), 30), 30), value);
				}
				catch(UsageException exc)
				{
					// rethrow to populate missing values
					throw new UsageException(obj, typeList, exc);
				}

				if (value != null)
				{
					try
					{
						data.setter.invoke(obj, value);
					}
					catch (IllegalAccessException e)
					{
						throw new InvalidCLIEntryException("Could not access setter for option ["
								+ data.clioption.name()
								+ "]", e, InvalidCLIEntryException.invalid_cause.class_access_error);
					}
					catch (InvocationTargetException e)
					{
						throw new UsageException(obj, typeList, "Error setting option value [" + data.clioption.name() + "]", e);
					}
				}
			}

			if (vbose)
			{
				verboseOutput(obj, valueMap);
			}

			invokeMain(obj, argv);

			return obj;
		}
		catch (ParseException e)
		{
			throw new UsageException(obj, typeList, e);
		}
		catch (UsageException exc)
		{
			throw exc;
		}
		catch (Exception exc)
		{
			throw new RuntimeException(exc);
		}
	}

	private static void assertValid(CLIOption annot) throws InvalidCLIEntryException
	{
		if (annot.required() && !annot.defaultValue().equals("\0"))
		{
			throw new InvalidCLIEntryException(
				"Required options cannot have default values: " + annot.name(),
				InvalidCLIEntryException.invalid_cause.bad_options
			);
		}

		// flags can only have a default value of 'true', 'false'
		if (annot.valueType() == CLIOption.value_type.not_allowed)
		{
			if (!annot.defaultValue().equals("\0") && !annot.defaultValue().equals("true") && !annot.defaultValue().equals("false"))
			{
				throw new InvalidCLIEntryException(
						"Flags cannot have default values: " + annot.name(),
						InvalidCLIEntryException.invalid_cause.bad_options
				);
			}
		}
	}

	private static void verboseOutput(Object ent, Map<String, Object> valueMap)
	{
		CLIEntry entr = ent.getClass().getAnnotation(CLIEntry.class);

		Map<String, Object> context = new HashMap<String, Object>();

		context.put("options", valueMap.entrySet());
		context.put("entry", entr);
		context.put("datetime", new Timestamp(System.currentTimeMillis()));

		String nick = entr.nickName();

		if (nick.equals("\0"))
		{
			nick = entr.getClass().getSimpleName();
		}

		context.put("nickName", nick);

		try
		{
			String template = IOUtils.toString(ent.getClass().getResource("/runtime_options.vm"));

			VelocityContext vcontext = new VelocityContext(context);

			VelocityEngine velocityEngine = new VelocityEngine();

			velocityEngine.init();

			StringWriter w = new StringWriter();

			velocityEngine.evaluate(vcontext, w, "log", template);

			System.out.println(w.toString());
		}
		catch (IOException e)
		{
			throw new RuntimeException(e);
		}
	}

	private static String print(Object value)
	{
		return String.valueOf(value);
	}

	private static void invokeMain(Object obj, String[] argv) throws InvocationTargetException, IllegalAccessException
	{
		Method [] methods = obj.getClass().getMethods();

		for (Method method : methods)
		{
			CLIMain climain = method.getAnnotation(CLIMain.class);

			if (climain != null)
			{
				// this is the one.
				if (method.getParameterTypes().length == 0)
				{
					method.invoke(obj);
				}
				else
				{
					// this one needs the string parameters
					method.invoke(obj, new Object [] {argv});
				}
			}
		}
	}

	private static CLIEntry.parser_type getParserType(Object obj)
	{
		CLIEntry clentry = obj.getClass().getAnnotation(CLIEntry.class);

		return clentry.parserType();
	}

	private static Object resolveOptionValue(Data data, CommandLine cl, String def) throws UsageException
	{
		boolean hasOption = cl.hasOption(data.clioption.name());
		String textValue = hasOption ? cl.getOptionValue(data.clioption.name()) : def;

		Object value = null;

		// we already validated earlier that there is exactly one parameter
		if (data.cardinality != cardinal_type.flag)
		{
			if (textValue == null)
			{
				return null;
			}

			if (data.cardinality == cardinal_type.array)
			{
				String[] optionValue = getArrayValue(textValue, data.clioption.valueSeparator());

				// validate against enumerations if present
				validateEnum(data, value);

				if (data.clioption.valueCardinality() != CLIOption.UNLIMITED_VALUES)
				{
					if (optionValue.length != data.clioption.valueCardinality())
					{
						throw new UsageException(null, null, "Wrong number of values supplied for option [" + data.clioption
								.name() + "]: " + optionValue);
					}
				}

				Object[] valueArr = optionValue;
				boolean[] bValueArr = null;
				int[] iValueArr = null;
				long[] lValueArr = null;

				if (data.type == data_type.bool)
				{
					bValueArr = new boolean[optionValue.length];
				}
				else if (data.type == data_type.Bool)
				{
					valueArr = new Boolean[optionValue.length];
				}
				else if (data.type == data_type.integer)
				{
					iValueArr = new int[optionValue.length];
				}
				else if (data.type == data_type.Integer)
				{
					valueArr = new Integer[optionValue.length];
				}
				else if (data.type == data_type.long_integer)
				{
					lValueArr = new long[optionValue.length];
				}
				else if (data.type == data_type.Long)
				{
					valueArr = new Long[optionValue.length];
				}
				else if (data.type == data_type.String)
				{
					valueArr = new String[optionValue.length];
				}

				for (int optIndex = 0; optIndex < optionValue.length; optIndex++)
				{
					if (data.type == data_type.Bool)
					{
						valueArr[optIndex] = Boolean.valueOf(optionValue[optIndex]);
					}
					else if (data.type == data_type.bool)
					{
						bValueArr[optIndex] = Boolean.valueOf(optionValue[optIndex]);
					}
					else if (data.type == data_type.integer)
					{
						iValueArr[optIndex] = Integer.parseInt(optionValue[optIndex]);
					}
					else if (data.type == data_type.Integer)
					{
						valueArr[optIndex] = Integer.parseInt(optionValue[optIndex]);
					}
					else if (data.type == data_type.long_integer)
					{
						lValueArr[optIndex] = Long.parseLong(optionValue[optIndex]);
					}
					else if (data.type == data_type.Long)
					{
						valueArr[optIndex] = Long.parseLong(optionValue[optIndex]);
					}
					else if (data.type == data_type.String || data.type == data_type.Object)
					{
						valueArr[optIndex] = optionValue[optIndex];
					}
				}

				if (bValueArr != null)
				{
					value = bValueArr;
				}
				else if (iValueArr != null)
				{
					value = iValueArr;
				}
				else
				{
					value = valueArr;
				}
			}
			else
			{
				String optionValue = textValue;

				if (!hasOption)
				{
					optionValue = def;
				}

				value = optionValue;

				validateEnum(data, value);

				if (data.type == data_type.Bool || data.type == data_type.bool)
				{
					value = Boolean.valueOf(optionValue);
				}
				else if (data.type == data_type.integer || data.type == data_type.Integer)
				{
					value = Integer.parseInt(optionValue);
				}
				else if (data.type == data_type.long_integer || data.type == data_type.Long)
				{
					value = Long.parseLong(optionValue);
				}
			}
		}
		else
		{
			//  determine the correct action here.
			// 1 - if there is no value supplied, chose a default based on whether the option was supplied.
			// boolean types use false, integral types get 0
			if (textValue == null)
			{
				switch (data.type)
				{
					case bool:
					case Bool:
						value = hasOption;
						break;
					case long_integer:
					case Long:
					case integer:
					case Integer:
						value = hasOption ? 1 : 0;
						break;
					case string:
					case String:
					case Object:
						value = hasOption ? "true" : "false";
						break;
				}
			}
			else
			{
				value = false;

				// try several methods for interpretation
				if (textValue.equals("1"))
				{
					value = true;
				}
				else if (textValue.equals("0"))
				{
					value = false;
				}
				else if (textValue.equals("true"))
				{
					value = true;
				}
				else if (textValue.equals("false"))
				{
					value = false;
				}
			}
		}

		return value;
	}

	private static void validateEnum(Data data, Object value) throws UsageException
	{
		String[] atr = data.clioption.enumeratedValues();

		if (atr.length != 0 && !(atr.length == 1 && atr[0].equals("\0")))
		{
			boolean match = false;

			for (String enu : atr)
			{
				if (enu.equals(value))
				{
					match = true;
					break;
				}
			}

			if (!match)
			{
				throw new UsageException(null, null, "Option [" + data.clioption.name() + "] value [" + value + "] does not match enumeration");
			}
		}
	}

	private static String[] getArrayValue(String textValue, String c)
	{
		return textValue.split(Pattern.quote(c));
	}
}